iT邦幫忙

2024 iThome 鐵人賽

DAY 13
2
Modern Web

為你自己寫 Vue Component系列 第 13

[為你自己寫 Vue Component] AtomicChip

  • 分享至 

  • xImage
  •  

[為你自己寫 Vue Component] AtomicChip

Chip 是一種小巧且功能多樣的元件,經常應用於描述內容的關鍵字來標記、分類或組織資訊。除了顯示資料的功能外,也偶爾會被應用於選擇、過濾的 UI 功能上。

元件分析

元件命名

相較於 Chip,對我們來說更直覺的名稱應該是 Tag。Chip 這個名稱源自於 Google 的 Material Design,Google 使用這個詞來描述一種小而集中的元件,類似於一小塊資料或訊息片段。在 Material Design 指南裡提到:Chip 幫助人們輸入資訊、做出選擇、過濾內容或觸發操作。它們最適合幫助使用者更快、更輕鬆地完成當前任務。因此,我們可以簡單地區分,如果設計這個元件的目的除了標記功能外還想涵蓋更多的互動功能,那麼我們可以選用 Chip 這個名稱;如果想強調只是單純的標記功能,那麼我們可以使用 Tag 這個名稱。

如果一個 UI Library 主打的是 Material Design 風格,那麼通常在這個 Library 裡面就會選用 Chip 這個命名。

元件架構

AtomicChip 架構圖

  1. Content:Chip 的內容。
  2. Prepend:Chip 的前置元素。
  3. Append:Chip 的後置元素。
  4. Delete:刪除按鈕。

功能設計

在開始實作前,我們先研究各個 UI Library 的 Chip / Tag 元件是如何設計的。

Element Plus

Element Plus Tag

<template>
  <ElTag type="primary">Tag 1</ElTag>
  <ElTag type="success">Tag 2</ElTag>
  <ElTag type="info">Tag 3</ElTag>
  <ElTag type="warning">Tag 4</ElTag>
  <ElTag type="danger">Tag 5</ElTag>
</template>

Element Plus 提供了五種不同的顏色樣式,在 type 這個 prop 上可以傳入 primary、success、info、warning、danger。這些顏色樣式可以幫助使用者更快速地辨識出不同的標記類型。如果想要自定義顏色,可以透過 color 這個 prop 來定義想要的標籤顏色。

除此之外,Element Plus 還提供了 closable 這個 prop,當設定為 true 時,標籤會出現一個關閉按鈕,使用者可以透過點擊關閉按鈕來移除標籤。

Vuetify

Vuetify Tag

<template>
  <VChip>Default</VChip>
  <VChip color="primary">Primary</VChip>
  <VChip color="secondary">Secondary</VChip>
  <VChip color="red">Red</VChip>
  <VChip color="green">Green</VChip>
</template>

Vuetify 一樣提供了幾種不同顏色的變化,使用 color 這個 prop 來設定顯示的顏色。如果想要自定義顏色,也可以在 color 這個 prop 傳入想要的色票。

另外,Vuetify 的 Chip 也支援使用 closable 這個 prop 來決定是否顯示刪除按鈕的功能。

除了上面呈現出來的以外,Element Plus 和 Vuetify 都提供了 size 這個 prop 來設定 Chip 的大小。在我們的 <AtomicChip> 也可以加入這個設計。

綜合以上並結合自身經驗,我們統整出 <AtomicChip> 的功能:

  • 可以透過 color 設定顏色。
  • 可以透過 size 設定大小。
  • 可以透過 deletable 設定是否顯示刪除按鈕。

如果有顯示刪除按鈕的話,我們可以透過 @delete 事件來接收刪除按鈕的點擊事件。

雖然我們參考的 UI Library 決定是否顯示刪除按鈕用的都是 closable 這個 prop,但是我自己感覺 deletable 這個 prop 更直覺,所以選擇使用 deletable 這個名稱。

使用結構如下:

<template>
  <AtomicChip
    variant="contained"
    color="primary"
    size="medium"
  />
</template>

元件實作

首先,我們將需求中提到的功能整理成 propsemit 的介面,我們會需要下列屬性:

Props

名稱 型別 預設值 說明
variant contained, outlined, text contained Chip 的樣式
color primary, success, warning, danger, info primary Chip 的顏色
size small, medium medium Chip 的大小
deletable boolean false 是否顯示刪除按鈕

Emits

名稱 型別 說明
delete (event: Event): void 點擊刪除 Chip 按鈕
interface AtomicChipProps {
  variant?: 'contained' | 'outlined' | 'text';
  color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
  size?: 'medium' | 'small';
  deletable?: boolean;
}

interface AtomicChipEmits {
  (event: 'delete', value: Event): void;
}

const props = withDefaults(defineProps<AtomicChipProps>(), {
  variant: 'contained',
  color: 'primary',
  size: 'medium',
  onDelete: undefined,
});

const emit = defineEmits<AtomicChipEmits>();

首先我們來規劃模板的部分:

<template>
  <span
    class="atomic-chip"
    :class="rootClass"
  >
    <span>
      <slot name="default" />
    </span>
    <template v-if="deletable">
      <button
        aria-label="Delete"
        type="button"
        @click="emit('delete', $event)"
      >
        <CloseSvg
          fill="currentColor"
          height="16"
          width="16"
        />
      </button>
    </template>
  </span>
</template>

接著整理 rootClass 後我們就完成了 <AtomicChip> 這個元件。

const BASIC_CLASS = 'atomic-chip';
const rootClass = computed(() => [
  `${BASIC_CLASS}--${props.size}`,
  `${BASIC_CLASS}--${props.color}`,
  `${BASIC_CLASS}--${props.variant}`,
]);

CSS 部分我們聚焦在 colorvariant 拼湊出來的組合。

這裡的作法與之前在 <AtomicButton> 裡面在處理 colorvariant 的作法有些不同。在這裡我們使用 CSS 變數來設定顏色變化,這樣的好處是我們可以得到更小的 CSS 大小。

.atomic-chip {
  // color
  @each $color, $value in $color-map {
    &--#{$color} {
      --chip-color: #{$value};
      --chip-color-second: #{rgba($value, 0.1)};
    }
  }

  // variant
  &--contained {
    color: var(--chip-color);
    background-color: var(--chip-color-second);
  }

  &--outlined {
    color: var(--chip-color);
    border-color: var(--chip-color);
  }

  &--text {
    color: var(--chip-color);
  }
}

這樣一來我們就完成了 <AtomicChip> 這個元件的實作。

AtomicChip

進階功能

color 接受傳入色票

除了預設的顏色外,我們也很常見到一些客製化的色票使用,這經常出現在可以自定義標籤的場景,如果我們想要支援 color 可以自定義顏色,我們可以如何實作呢?

色碼的表示方式很多種,為了不要元件過度複雜,但有保有自由定義顏色的彈性,因此我們的 <AtomicChip>color 僅支援 HEX 格式的色碼。

首先我們先檢查開發人員傳入的色碼是否為我們預設可以接受的色碼,如果是才把對應的 CSS 變數設定上去。

const THEME_COLORS = ['primary', 'success', 'warning', 'danger', 'info'];
const isThemeColor = computed(() => {
  return THEME_COLORS.includes(props.color);
});

const BASIC_CLASS = 'atomic-chip';
const rootClass = computed(() => [
  `${BASIC_CLASS}--${props.size}`,
  `${BASIC_CLASS}--${props.variant}`,
  isThemeColor.value ? `${BASIC_CLASS}--${props.color}` : null,
]);

接著我們把開發人員傳入的顏色色碼用 style 的方式設定上去。

const rootStyle = computed(() =>
  !isThemeColor.value
    ? {
        '--chip-color': props.color,
      }
    : null,
);

不過在這裡遇到一個困難,前面我們在 CSS 中用了兩個變數,一個是 --chip-color 另一個是 --chip-color-second--chip-color-second 的值是 --chip-color 的 10% 透明度。

如果開發人員傳入的色碼為 #009999 我們要如何得到這個色碼的 10% 透明度呢?

轉換為 HEX + A(透明度)格式

在 HEX + A 色碼中,透明度的值是 00FF 之間的數字,我們可以把這個數字轉換為 10 進位的數字,01 之間。

我們知道 FF25516^2 - 1),所以我們可以把透明度的值乘上 255 再轉換為 16 進位的數字。

在 CSS 中我們的透明度是 0.1,所以我們可以得到 0.1 * 255 = 25.5,將 25.5 四捨五入到最接近的整數後轉換為 16 進位的數字就是 1A

因此我們只要在開發人員傳入的色碼後面加上 1A 就可以得到透明度的色碼了。

#009999 -> #0099991A

因此我們的 rootStyle 可以擴充如下:

const opacity = Math.round(0.1 * 255).toString(16)
const rootStyle = computed(() =>
  !isThemeColor.value
    ? {
        '--chip-color': props.color,
        '--chip-color-second': `${props.color}${opacity}`,
      }
    : null,
);

但是,如果遇到開發人員傳入的是三位數的色碼,這個方法就行不通了。

#099 -> #0999A(不是正確的色碼)

因此當遇到三位數的色碼,我們需要先把色碼轉換為六位數的色碼,以下方法可以將色碼展開為六位數的色碼。

function toExpandedHex (color: string): string {
  const match = color.match(/[a-f0-9]{6}|[a-f0-9]{3}/i);
  if (!match) return '#FFFFFF';

  let colorString = match[0];

  if (colorString.length === 3) {
    colorString = colorString
      .split('')
      .map(char =>  char + char)
      .join('');
  }

  return `#${colorString}`;
}

再調整一下 rootStyle

const opacity = Math.round(0.1 * 255).toString(16)
const rootStyle = computed(() =>
  !isThemeColor.value
    ? {
        '--chip-color': props.color,
        '--chip-color-second': `${toExpandedHex(props.color)}${opacity}`,
      }
    : null,
);

這樣我們就可以自定義 <AtomicChip> 的顏色了。

AtomicChip Custom Color

並加到模板上後,我們就可以得到一個允許自定義的 <AtomicChip> 元件了。

<template>
  <span
    class="atomic-chip"
    :class="rootClass"
    :style="rootStyle"
  >
    <!-- 略 -->
  </span>
</template>

不過型別定義部分就會比較困難了。

interface AtomicChipProps {
  color?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | string;
}

這樣在使用這個元件時,除了知道 color 接受的是一個字串外,其他的資訊就會失去。這是因為 primary 涵蓋在 string 這個型別裡面,不經過特殊處理的話開發人員就無法透過 TypeScript 的型別提示得知有哪些屬性可以選擇。

提供一個有效的解決方法:

type LiteralUnion<T> = T | (string & {});

interface AtomicChipProps {
  color?: LiteralUnion<'primary' | 'success' | 'warning' | 'danger' | 'info'>;
}

這樣在開發上,開發人員除了可以保有 primarysuccesswarningdangerinfo 這幾個選項外,也可以輸入任意字串自定義自己想要的顏色。

但因為這不是我們這次主要討論的重點,所以我們就不深入探究這個問題了。有興趣的話,這裡附上 GitHub Issue 討論的連結:Literal String Union Autocomplete

自定義渲染元素

在介紹元件時我們提到 Chip 這個元件除了可以用來標記外,也可以用來做一些互動功能。為了能夠對渲染出正確的 HTML 標籤,我們可以新增一個 as 的 prop 來決定渲染出什麼樣的 HTML 標籤。

interface AtomicChipProps {
  as?: any;
  // 略
};

const props = withDefaults(defineProps<AtomicChipProps>(), {
  as: 'span',
  // 略
});

並且在模板中我們使用 Vue 內建元素 <component> 來動態渲染元素。

<template>
  <component
    :is="props.as"
    class="atomic-chip"
    :class="rootClass"
    :style="rootStyle"
  >
    <!-- 略 -->
  </component>
</template>

這樣我們就可以隨著使用的情境來不同地使用 HTML 元素甚至是元件了。

<template>
  <AtomicChip
    as="AtomicLink"
    href="https://www.google.com"
  >
    Google
  </AtomicChip>
</template>

總結

這次我們實作了一個 <AtomicChip> 元件,元件本身實作非常簡單單純,我們也討論了命名上的選用因素。在建立自己的元件庫時,除了很直覺的 Tag 之外,我們也多了一個可以思考的方向。另外,在進階需求中我們也嘗試讓 color 可以支援自定義的顏色,並讓型別支援更加彈性。

最後,我們也讓元件可以隨著使用情境不同,開發人員可以自設定要使用的 HTML 標籤或元件,讓元件在應用上更加彈性。

參考資料


上一篇
[為你自己寫 Vue Component] AtomicBadge
下一篇
[為你自己寫 Vue Component] AtomicDivider
系列文
為你自己寫 Vue Component19
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言